# Authentication and route protection

> Supacharger uses Supabase Auth with `@supabase/ssr` and the Next.js 16 `proxy.ts` convention. Proxy refreshes the session and makes optimistic page-routing decisions; it is not the authoritative authorisation boundary. Route Handlers, Server Functions, data services, grants, and Row Level Security (RLS) must validate access independently.

# Authentication and route protection

Supacharger uses Supabase Auth with `@supabase/ssr` and the Next.js 16 `proxy.ts` convention. Proxy refreshes the session and makes optimistic page-routing decisions; it is not the authoritative authorisation boundary. Route Handlers, Server Functions, data services, grants, and Row Level Security (RLS) must validate access independently.

## Server-side identity checks

The Proxy always calls `supabase.auth.getClaims()` exactly once. It verifies the JWT signature and expiry and normally avoids an Auth-server round trip when asymmetric signing keys are enabled. This is a fixed security invariant rather than an application configuration choice. `AUTH_SESSION.ALLOW_ANONYMOUS_USERS` remains application-configurable.

Before it creates that request-scoped Auth client, Proxy strips known password, email, MFA and organisation-registration fields from query strings on authentication routes and redirects to the sanitised URL. Safe return parameters remain intact, and a valid `lang` handoff is captured before removal. This is defence in depth for a form rendered before client hydration; Auth forms must still submit with POST and validate again at the protected server boundary.

Managed authentication contract tests remain portable across applications: they validate supported configuration shapes, exact managed route/form behaviour and the semantic presentation interface, while each application's selected passwordless mode, confirmation policy, sidecar contents, CSS values and root layout stay developer-owned.

Do not trust `getSession().user` for server-side authorisation. Supacharger excludes anonymous Supabase Auth users by default even though their database role is `authenticated`; an application must deliberately opt in to accept them.

## Proxy response invariant

The request-scoped Supabase client runs exactly once per matched request. When it refreshes or clears a session, Supacharger carries all of the following into every continuation and redirect:

- mutated request cookies for downstream Server Components;
- response `Set-Cookie` values for the browser; and
- `Cache-Control`, `Expires`, and `Pragma` supplied by `@supabase/ssr`.

Do not create a redirect, rewrite, error response, or replacement `NextResponse.next()` without copying this session response state. The anti-cache headers prevent an intermediary from caching a response containing another user’s auth cookies.

## Route policy

Static page policy remains application-owned in `src/supacharger.config.ts`:

```ts
AUTH_ONLY_APP: true,
PATH_AUTH_GUARD: {
  UNAUTHED_USER: {
    ALLOWED: ['/', '/account/login', '/auth/callback'],
    DISALLOWED: [],
  },
  AUTHED_USER: {
    DISALLOWED: ['/account/login'],
  },
},
```

Keep the leading slash. `/:path*` denotes a trailing wildcard. Proxy redirects are a usability feature only: API routes return their own `401` or `403`, Server Functions validate their caller, and RLS remains the final data boundary.

The protected route-policy implementation supports exact literals, one-segment parameters such as `/organisations/:handle`, and trailing wildcards such as `/docs/:path*`. Unauthenticated protected API paths receive a JSON `401`; page paths redirect to the configured login destination. Use `copySupabaseResponseState` whenever an application adds a redirect or replacement response.

The Proxy performs no database RPCs. The protected authenticated layout calls the shared server-access helper after routing, where it can enforce profile completion and billing policy with the current request's claims. Routes declared in `PATH_AUTH_GUARD.UNAUTHED_USER.ALLOWED` remain public for authenticated users with incomplete profiles and bypass this protected layout. Product-specific membership, organisation, or permission checks extend that server boundary; they do not belong in the Proxy hot path.

## Authentication journey

Supacharger owns the public route scaffolds and accessible form structure:

| Purpose                   | Managed route                 |
| ------------------------- | ----------------------------- |
| Sign in                   | `/account/login`              |
| Create account            | `/account/create`             |
| Passwordless entry/status | `/account/login-magic`        |
| Request password reset    | `/account/reset-password`     |
| Set a new password (recovery session required) | `/account/reset-password/new` |
| OAuth/recovery PKCE callback | `/auth/callback`           |
| Email/OTP confirmation    | `/auth/confirm`               |
| Safe authentication error | `/auth/error`                 |

These pages live under `src/app/(supacharger)/(unauthenticated)/`. Do not create another page with the same public URL under `(project)`, because two route groups cannot own the same Next.js route. The managed shell imports `AuthSidecar` and `AuthMobileBrand` from `src/supacharger.adapters/auth/auth-sidecar.tsx`. Managed forms expose stable `sc-auth-*` hooks, and `src/styles/supacharger-auth.css` supplies their product presentation. The CLI installs missing starter versions of those two developer-owned files and preserves them on later updates.

The shared clean-room journey is shipped to Core, SnapScreen, and Wakekeeper, using SnapScreen as the approved visual and interaction basis. Those projects preserve its strong heading treatment, progressive provider-to-email transition, dark auth surfaces, branded primary actions, and matching password-reset presentation. Auth buttons and input fields use the same 56 px control height and 20 px by 12 px internal padding, preventing primitive component defaults from changing their relative dimensions. The password-requirements checklist is anchored below its input as a floating panel: showing it does not change form height, move the confirmation field, or cover the password input. Signed-in password and confirmation fields remain on separate rows at every viewport width. Below the `lg` breakpoint, the hidden left panel hands logo presentation to a single logo above the login or signup content. At `lg` and wider, the inline logo is hidden and the left-panel logo is shown. Project copy, colours, imagery, enabled methods, CSS, and sidecar adapters remain developer-owned.

The journey supports password sign-in/sign-up, exclusive passwordless OTP or link modes, dynamically sized OTP entry, resend cooldown, correct browser autocomplete purposes, and provider buttons. Passwordless sign-in and password-signup verification keep distinct wording and verification purposes. A safe relative `next` path survives route switches, sends, resends, and verification. The bare root destination `/` is implicit, so redirects omit the redundant `?next=%2F`; a root path with a query string or fragment remains explicit. Password, link, and OTP submissions retain independent busy indicators. New shared states extend the SnapScreen-based presentation rather than replacing it with a generic form. Specdrive consumes the same routes and behaviour through its authorised developer-owned presentation; its old `/auth/login`, `/auth/sign-up`, `/auth/forgot-password`, and `/auth/update-password` pages remain for one documented release as redirects that retain only a validated internal `next` destination.

```ts
AUTHENTICATION: {
  EMAIL_PASSWORD: { SIGN_IN: true, SIGN_UP: true },
  PASSWORDLESS_EMAIL: {
    SIGN_IN: 'otp', // 'disabled' | 'otp' | 'link'
    SIGN_UP: 'disabled',
    OTP_LENGTH: 6, // 6 through 10
  },
  SIGN_UP_EMAIL_VERIFICATION: 'otp', // also supports 'otp-and-link'
  MFA_TOTP: { REQUIRED_FOR_SIGN_IN: false },
},
```

UI and server actions enforce these options. Password signup crosses a Zod-validated Server Action boundary before calling Supabase; the browser form is not the trust boundary. Passwordless sign-in calls `signInWithOtp()` with `shouldCreateUser: false`; entered codes verify with `type: 'email'`. Its hosted **Magic Link or OTP** template contains either `{{ .Token }}` for OTP mode or the login link for link mode, never both. `OTP_LENGTH` drives the field count, validation, and sign-in copy. Password signup remains independent: its confirmation code verifies with `type: 'signup'`, and `SIGN_UP_EMAIL_VERIFICATION` controls the Confirm signup template. Password signup may use `otp-and-link`; consuming either credential invalidates the other. Logout posts once through the shared server route so SSR cookies are cleared before navigation. The POST response supplies a safe relative `redirectTo` path; direct GET logout uses a relative `Location` header with status `303`. The browser resolves either path against the origin it opened, so neither flow depends on the configured Site URL for its final navigation.

Changing a signed-in user's password posts to `/api/account/update-password`. The protected handler strictly parses the current/new password payload, verifies the user, reauthenticates with Supabase Auth `signInWithPassword`, applies the configured password-strength policy, and calls `updateUser`. Supacharger does not expose a password-comparison database RPC.

The OTP and link in one signup email are two ways to complete the same one-time verification. If someone enters the OTP and then clicks the link, Supabase rejects the consumed link as expired or invalid. Supacharger handles that expected state without exposing a generic error page: it checks the current Auth user, sends a confirmed signed-in browser to the safe configured app destination with an explanatory toast, and sends a browser without a session to the configured login page with an expired/already-used notice. The redirect never retains the token hash or accepts a protocol-relative `next` value. Keep `AuthConfirmationNotice` mounted in the application root layout so one-time confirmation notices are shown and removed from the URL. A local `supabase/config.toml` or template does not update hosted Auth settings or templates.

Passwordless link mode returns through `/auth/confirm`; OTP mode verifies directly from the login form. `/auth/callback` exchanges one-use PKCE codes for OAuth and password recovery. Recovery links carry `flow=recovery`; after exchange, Supacharger verifies the JWT recovery authentication method before opening `/account/reset-password/new`. A local `supabase/config.toml` or template does not update hosted Auth settings or templates.

See [Supabase Auth coverage](./authentication-coverage.mdx) for a tick-and-dash comparison of email, OAuth, passkey, SAML, Web3, anonymous, MFA, and OAuth-server support, plus every social provider exposed by Supacharger. Phone authentication is intentionally excluded from that page.

Password sign-in navigates directly to the login destination, while the canonical protected server layout independently enforces configured profile and billing access. Proxy remains claims-only. APIs and Server Actions outside that layout still require their own entitlement checks. Read [Login redirects and subscription paywalls](./login-redirects-and-paywalling.md) for the exact precedence and server-side enforcement requirements.

Branding, translated copy, additional authentication fields, and product onboarding stay in developer-owned paths. Organisation creation or switching occurs only after authentication through `api.organisations(input_payload)`, which derives user/session identity from the JWT; an organisation handle is not stored as arbitrary user metadata.

## Mobile verification callbacks

`MOBILE_DEEP_LINKING` configures the generated iOS Universal Link and Android App Link association endpoints. Use it when magic links, link-based confirmation, or password reset should return to an installed mobile app. Email OTP verification remains an in-app code-entry flow and does not require a deep link.

The callback must return to the same client that initiated PKCE. A wrapped web app reloads the verified HTTPS URL in its persistent authentication web view; a fully native app completes the exchange through its native Supabase client. See [Mobile login verification with deep links](./mobile-auth-deep-linking.md) for the complete configuration, Xcode, Android manifest, callback, and verification procedure.

## Passwords and reset

Configure the same password minimum and complexity in Supacharger and the Supabase Auth provider settings. `PASSWORD_CUSTOM_REGEX` is enforced by both the browser checklist and the server-side recovery/update boundary; it does not change Supabase Auth’s own password policy. Password-reset email redirects to `/auth/callback?flow=recovery`; allow `/auth/callback` in each hosted Supabase project and set `NEXT_PUBLIC_SITE_URL` correctly. The callback exchanges the PKCE code once on the server, and `/api/account/recover-password` requires a verified recovery AMR before applying the new password.

`NEXT_PUBLIC_SITE_URL` is required and is the single runtime source for the application's canonical origin. Supacharger validates it with `getSiteUrl()` and builds authentication destinations with `getURL(path)`. Do not add localhost or production fallbacks in source code: a missing value fails clearly so redirects cannot silently target the wrong application or port. A local Supabase `config.toml` can reference the same environment variable, but hosted Supabase URL Configuration remains a separate dashboard setting.

Use the [hosted setup wizard](../guides/setup-wizard.md) to configure the environment variables, Site URL, exact callback allow-list, email policy, SMTP, and templates in the required order.

## Roles and custom claims

The canonical migration creates private `app.user_roles` data and `app.custom_access_token_hook(jsonb)`. The hook adds a top-level `user_role` claim whenever Supabase Auth issues or refreshes a JWT. The browser cannot write the role table or execute the hook.

See [Roles and custom claims](./roles-and-custom-claims.md) for setup, role assignment, RLS examples, token-refresh behaviour, and the Specdrive-style active-organisation extension.

## CLI workflow

Auth hooks and role changes are schema changes. Create and verify them through the project-scoped Supabase CLI:

```bash
npx supabase migration new describe_the_change
npx supabase db reset
npx supabase db lint --local
npm run generate-types
```

Keep `[auth.hook.custom_access_token]` enabled in `supabase/config.toml`. For a hosted environment, select `app.custom_access_token_hook` in **Authentication → Hooks** after deploying the migration. Preview linked database changes before applying them; never treat a local `config.toml` edit as proof that a hosted dashboard setting changed.

The first-deployment sequence and its verification checks are documented in [Connect hosted Supabase](../guides/hosted-setup/02-connect-hosted-supabase.md) and [Verify production](../guides/hosted-setup/08-production-verification.md).
